iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
AI Engineering

AI 的駕馭之道:一個 AI Code Reviewer 的養成、評測與邊界實錄系列 第 21

Day 21|用 OpenTelemetry 幫 AI 審查跑一次 Profiler:量出最冤枉的 13 分鐘

  • 分享至 

  • xImage
  •  

Day 21:用 OpenTelemetry profiling 找出投影與驗證的 803 秒

簡短回顧

昨天牆立起來之後,我開始想知道容器裡面到底發生了什麼。一場審查跑四十分鐘,時間跟錢到底花去哪了?主 agent 在忙什麼?那一群 subagent 各自佔了多少?

我要的不是監控,是 profiling

其實 OpenTelemetry 這個詞我知道很久了。它是可觀測性的開放框架,替 traces、metrics、logs 提供一致的資料模型與匯出管道,Claude Code 也原生支援,設幾個環境變數它就會把執行過程吐出來。但我一直不知道這個功能可以為我帶來什麼。接個監控儀表板嗎?一個我自己用的審查工具,監控要給誰看?

直到費用優化這個題目出現,我才想清楚我要的是什麼:各個階段的時間、主 agent 跟 subagents 各自在忙什麼、每個環節的花費。講白一點,我想做的事情叫 profiling。就像抓程式效能會掛 profiler、看火焰圖找熱點一樣,只是這次被剖析的對象是一條 review 流程,單位從 function 換成了 agent。

Monitoring 與 profiling 回答的是不同問題。Monitoring 持續觀察系統是否正常、何時偏離;profiling 則往下追,回答時間與資源究竟花在哪裡。兩者並不以「是否長期執行」區分,profiling 也可以持續進行。

嚴格來說,這次我沒有使用 OpenTelemetry 尚在發展中的 Profiles signal,而是把一場 review 的 trace 當成流程層級的 profiler:帶著具體問題開始量,找到瓶頸、做完決定,就可以關掉。這一天我的問題很具體,所以接下來的每一步都有方向。

後面先記住三個層級:Claude Code 每收到一次 user prompt,會開一筆以 claude_code.interaction 為根的 trace;裡面的 LLM request、tool call 與 subagent 生命週期是 span;skill.versionexperiment 這類 resource attributes,則是黏在觀測資料上的標籤。同一個 session 如果有多次互動,可能跨越多筆 trace,之後再靠 session.id 聚合。

先讓它吐資料

Claude Code 的 telemetry 是原生的,環境變數開起來就有:

相關的官方文件

本文對應的設定與報表程式都放在公開 repo 的 opentelemetry/ 目錄。

export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_LOG_TOOL_DETAILS=1    # 沒有這個,tool 呼叫的參數與輸入不會進 telemetry

# 方式一:直接印在 console(快速確認用;注意它寫 stdout,會跟 claude -p 的結果混在一起)
export OTEL_METRICS_EXPORTER=console
export OTEL_LOGS_EXPORTER=console

# 方式二:traces 走 OTLP 送給 Jaeger(本文主用)。trace 還掛在 beta 旗標後面,
# 2.1.222 實測:不開下面第一行,Jaeger 連一筆都收不到
export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1
export OTEL_TRACES_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
# Claude Code 直接跑在 host 時用 localhost;本文的 dev container 會由 run script
# 自動改注入 http://jaeger:4317,不必在容器裡照抄 localhost
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317

OTEL_LOG_TOOL_DETAILS=1 會把路徑、URL、Bash 指令與工具參數送進觀測後端;這些欄位可能含有機敏資訊,開啟前要先決定誰能讀、要保存多久,以及是否需要遮罩。

Jaeger 用一個 docker compose 就起得來。v2 以單一 binary/image 發布,再由設定決定要扮演 all-in-one、collector、query 等角色;本文這份 config 把收集、儲存與查詢 UI 放在同一個容器裡,也就是官方所稱的 all-in-one。OTLP gRPC 走 4317 收進來,UI 直接看每場 run 的時間瀑布。位置上它跟 Day 18 的 GitLab 代理掛在同一張 docker network,dev container 本來就會接上那張網,容器內用 jaeger:4317 就直達;run script 偵測到 Jaeger 容器在跑才注入這些環境變數,沒跑就完全不碰,平常零負擔;就算配置好了,進容器的啟動選單還會再問一次要不要送:跟昨天網路能力那題同一個道理,要不要被記錄,是人每一場自己做的選擇。

有一個細節值得講:昨天那道牆不用為它新增對外出口。防火牆原本就放行容器接上的直連網段,Jaeger 也在同一個網段裡,telemetry 的封包不必離開這個環境。代價是 Jaeger 會多保存一份觀測資料。本文這個示例沒有再替 Jaeger 加上登入、保存期限或欄位遮罩;若要帶進多人或長期運作的環境,這些會是下一層需要補上的治理。

跑完一場,它就在這裡等你:

Jaeger 搜尋頁:service 選 claude-code,每次 interaction 對應一筆 trace

點進去就是一次 interaction 的時間瀑布:由這次 prompt 觸發的 tool 呼叫、LLM 請求與 subagent 生命週期,會攤在同一條時間軸上。這就是 profiler 的視覺語言:

trace 時間瀑布:tool/llm_request 的 span 階層

第一個問題:每個 subagent 都叫 custom

先講清楚一件事,免得跟前面幾天對不上:以下的數字來自我原本在用的那套,不是我們前面一起建的這份。那一套多了一條掃 git 歷史憑證的 gitleaks 軌道,也多了兩個後面會被我砍掉的角色,公開的這版都沒有。

我的審查 skill 從三月底就開始派 subagent 平行做事,linter、trivy、gitleaks、opengrep、品質盲檢,到六月已經十一個派遣點。接上 telemetry 之後,第一件想知道的事就是每個角色各花多少。結果 agent.name 這個維度上,自訂 agent 一律叫 "custom"。派了十一種角色,報表說他們是同一個人。

當時(Claude Code 2.1.193)實測下來的行為,跟文件讀起來的預期差很多(agent.name 那條現在文件上有寫了):

  1. 派遣時寫的 description,在任何 metric 跟 event 裡完全看不到
  2. metric 的 agent.namequery_source 對自訂 agent 塌成 custom,內建 agent 反而保留原名
  3. claude_code.subagent_completed 這個事件的 agent_type 欄位,自訂名保留原樣,同一筆還帶 total_tokensduration_msmodel
  4. 在我當時使用的 console metrics exporter 裡,cost 跟 token 的 metric 是累積型(cumulative)計數器,每個 export 週期重新吐一次「目前累積值」,直接全部加總會得到十幾倍的假數字,只能取每個 series 的最終值

第 3 點是關鍵。照文件讀會以為「自訂 agent 一律塌成 custom」,實測才發現只有 metric 那兩個維度塌,agent_type 不塌,這條後來成了整套歸因的主路徑。(寫稿前我在 2.1.222 重驗過一輪:2、3、4 仍然成立;1 軟化了,開 OTEL_LOG_TOOL_DETAILS 後 description 會出現在 tool_result 的參數 JSON 裡,但仍然當不了 group by 的維度。)

在被量測物身上做記號

所以要分出誰是誰,只能靠被量測物自己身上的記號。這時候該把 Day 7 埋的梗挖出來了:當時規劃 subagent 派遣,我寫了兩條看起來像碎念的規格:「subagent_type 會是 ncr-* 的格式」、「Prompt 第一句加上 ncr-* Tag,未來執行分析時可以直接 grep 這樣的 Pattern 來定位」。那個「未來」,就是今天。

第一個記號,每個派遣 prompt 的第一行固定放一個可以 grep 的 tag,自成一行:

[ncr-scan-lint]

subagent 讀到會直接忽略這行,但它會跟著 prompt 一起進 telemetry(前提是有開 OTEL_LOG_TOOL_DETAILS),事後一個 grep 就能撈出所有派遣。

第二個記號,幫每個角色建一個具名的自訂 agent 檔,派遣時指定 subagent_type

---
name: ncr-scan-lint
model: sonnet
---
Follow your dispatch prompt.

歸因需要的最小形狀就這樣,一句話:讓 subagent_completed.agent_type 帶著角色名回來的只有那個 name(repo 裡的正式版本後來各自長成了完整 runbook,那是為了別的目的)。有了它,group by 就有每角色的 token 跟時間。

兩個記號互相印證,但扮演的角色不同。正常情況下,報表沿 span 的父子鏈找到派遣 span,再用 subagent_type 做結構化歸因;如果環境裡沒有 agent 檔、派遣退回 general-purpose,prompt tag 至少還能讓我人工反查角色。老實說,寫下「未來可以 grep」的時候,我並不知道那個未來長什麼樣;接上遙測這天,它真的成了一條復原線索。

記號真的進了 telemetry。Jaeger 瀑布裡點開一個派遣 span,subagent_typeskill.versionexperimentsession.id 一次到齊(這顆就是一場真實審查裡 ncr-quality-check 的派遣,7 分多鐘):

Task 派遣 span 的 tags:subagent_type 與 resource attributes 全都在

把版本號跟實驗代號黏在每筆資料上

只量一次是看熱鬧,要回答「改了有沒有效」需要對照組。OTel 的 resource attributes 就是做這件事的:啟動時設一個環境變數,該 session 的所有資料都會黏上這些標籤。我讓 run script 自動組:

OTEL_RESOURCE_ATTRIBUTES="skill.version=${SKILL_VER},experiment=${NCR_EXPERIMENT:-none},..."

skill.version 從 skill 檔案自動抓,experiment 由環境變數指定。之後要做前後比較,姿勢就是:

NCR_EXPERIMENT=before-slimgen ./run-ncr-dev-container.sh   # 改之前跑
# ……改 skill……
NCR_EXPERIMENT=after-render ./run-ncr-dev-container.sh     # 改之後跑

事後按 experiment 這個維度切開來比。這層是設定當下花五分鐘、之後每一次優化都在用的地基。開發當下改了什麼變因,就記進什麼標籤,資料收下來,未來才有東西可以分析。

這其實就是實驗設計,我在醫檢那行練過的老規矩:操縱變因是 skill 版本,應變變因是時間跟錢,其他能固定的(同一個 MR、同一台機器、同一個模型版本)通通是要釘住的控制變因。變因沒有先講清楚,量出來的差異就不知道該歸給誰。experiment 標籤做的事,就是把「這一場動了什麼變因」寫死在資料上,不必靠事後回憶。

熱點:最後三分之一的時間,在重打一份已經寫完的報告

儀器接好當天,第一個熱點就浮出來了。

當時的報告流程是這樣:主 agent 寫完完整版報告(Markdown,落地存檔),接著為了貼到 MR comment,派一個 subagent 把它投影成精簡版。這個 subagent 的 runbook 白紙黑字寫著它是 formatter 不是 reviewer,審查發現要逐字照抄、不准重新判斷。抄完之後,因為怕它抄漏,再派第二個 subagent 拿完整版跟精簡版逐條比對,回 PASS 或 FAIL。

你看出問題了嗎?我付錢請一個被禁止思考的 LLM 把整份報告重打一遍,然後再付錢請另一個 LLM 檢查它有沒有打錯。而且驗證的那位跑在 opus 長上下文模式,比被驗證的還貴。

當天時間瀑布一攤開,投影那一步就孤零零掛在整場的最尾端。隔天早上我補了一組嚴謹的對照:同一個內部 MR(6 個檔案、190 行變更),新舊兩版 skill 各完整審一次。數字攤開:

角色 改之前 改之後
ncr-scan-opengrep 941s/$2.52 490s/$1.40
ncr-quality-check 433s/$1.81 424s/$2.25
ncr-slim-gen(投影) 509s/$1.12 不存在了
ncr-slim-gate(投影驗證) 294s/$1.35 不存在了
ncr-scan-trivy 147s/$0.38 180s/$0.44
ncr-scan-lint 117s/$0.45 103s/$0.34
ncr-scan-gitleaks 50s/$0.17 34s/$0.13
整場 wall-clock 42 分 28 秒 24 分 39 秒
subagent 總花費 $7.80 $4.56

(錢都是 token 乘牌價的估算上界,遙測記的是 token 不是帳單。絕對秒數是在 macOS 的 Docker Desktop 裡量的:這台 Mac 用了五年,再疊一層虛擬化,同一場跑起來大概比原生 Linux 慢上一倍,所以看相對變化就好,絕對值沒有參考意義。)

同一份數字畫開來看(這張是從救回的 A/B raw trace 直接算出來的,計算走的就是 repo 裡 opentelemetry/telemetry-report.py 的角色歸因邏輯):

改前/改後每角色耗時對比

把時間軸攤開,情況更難看。四條掃描軌道是平行跑的,品質盲檢也早就結束,但投影與驗證近乎依序排在最尾端:改之前那場的最後 799 秒,快 13 分半、大約整場三分之一的 wall-clock,全部花在「投影、再驗證投影」這兩步上(兩步耗時合計 803 秒,頭尾交疊了 4 秒,所以尾段跨度是 799)。我就坐在那裡等一台影印機。

改之前那場的時間軸:尾端 799 秒近乎依序執行

兩件事要老實講。第一,這是一場對一場的比較,n=1,而且 opengrep 那軌在兩場之間自己就差了 450 秒(它在跟 semgrep 規則檔的相容性搏鬥,跟這次優化無關),主 agent 的花費甚至是上升的($7.51 → $9.80,部分工作移回主 agent 做了)。所以「42 分變 25 分」我不會拿來當主張,站得住的主張是:被砍掉的那兩步本身,803 秒與 $2.47,變成 0。第二個數字對我來說更重要:兩場的主要審查結論一致,都是 Approved with Comments、Critical 0、Suggestion 2;新版另外多抓了 3 個 Nit。這條管線消失,在這一組對照裡,主要審查結論沒有跟著消失。

解法本身不新奇:報告本體從 Markdown 改成 JSON,每個欄位有 schema 定義,精簡版變成一支 Python 對同一份資料套另一組視圖規則、渲染出 Markdown。renderer 近乎即時,也不再產生模型 token 成本。更關鍵的是第二個 LLM 也不需要存在了:內容對應不再交給 LLM 自由生成,而是成為可由 schema、deterministic renderer 與測試查驗的契約,不用再花一個 LLM 去驗另一個 LLM。

時序我自己回看也覺得有戲:早上 10:38 儀器接上,晚上 18:54 熱點砍掉,18:57 連那兩個角色的 agent 檔一起刪了。開著 profiler 量、量到、改完、把該收的收乾淨,一天。

錢,還是切不到每個角色頭上

有一題到今天在 OTel 管道內都沒有解:每個角色「精確」花了多少錢。cost 相關的記錄只帶那個塌掉的 agent.name,所以每角色的錢只能按 token 占比去估,上面表格的 $ 全是這種估算。

當時我的替代路線是 transcript:Claude Code 的對話記錄(~/.claude/projects/ 下的 JSONL)每筆請求都有精確的 usage,ccusage 這類工具就是解析它在算錢的。六月那時我只讀主對話那一份 JSONL,算得出 session 總額,切不出角色。

後來才知道這件事早就補齊了,是我沒去找:每個 session 目錄下就有 subagents/ 子目錄,每個 subagent 一份獨立 transcript 加一份 meta,meta 裡直接寫著身分(我自己是 2.1.222 重驗時才看到,2.1.251 仍是這個形狀):

{"agentType": "ncr-scan-lint", "toolUseId": "toolu_01...", "spawnDepth": 1}

拿 meta 辨識角色、取得各檔案的精確 token 用量,再依當時牌價換算,就能得到每個角色的估算成本。這條路我後來直接寫成了 repo 裡的 opentelemetry/cost-report.py

uv run opentelemetry/cost-report.py                          # 目前專案最新的 session
uv run opentelemetry/cost-report.py <session.jsonl 或其目錄>  # 指定某一場

我也實測驗過 ccusage 對新格式的行為:subagents/ 有被算進去(跟我自己逐行加總的結果完全一致);去重是對每則訊息取最終值,因為 streaming 期間同一則訊息會寫進多行、output 數一路遞增,取第一行會少算好幾倍。

至於六月那兩場對照的 transcript?被預設 30 天的清理機制吃掉了,所以那兩場的錢永遠停在估算上界。遙測那份倒是從 Jaeger 的儲存底層撈回來了,撈的過程夠寫另一篇,先不展開。

跑出來長這樣,每個角色的 model、token 四項與估算成本一行一個,總額與 ccusage 的計算結果一致:

cost-report:每角色 token 用量與估算成本,總額與 ccusage 計算結果一致

另一支 opentelemetry/session-report.py(跟 cost-report 一樣就在 repo 裡)則是把一場審查的三個資料源收進同一份互動 HTML:產出是一頁網頁,甘特可以縮放、span 可以 hover。下面拿另一場真實審查當例子,不是上面 A/B 那兩場:

uv run opentelemetry/session-report.py 17c7d838 --open   # session id 前綴即可

最上面三張事實卡,結論來自審查報告的 JSON、時間來自 trace、錢來自 transcript,各源各管各的(內部 MR 標題已打碼):

場次報表:三張事實卡

中段是甘特時間軸。這場審查完等了 35 分鐘才回來下發佈指令,這段空窗用 ⫽ 斷軸壓掉,不讓它把有事發生的部分擠成一條線:

場次報表:甘特時間軸與空窗斷軸

hover 到任何一個 span,能看到那一次請求的輸出 token 跟快取命中:

hover 單一請求的 tooltip:快取命中 93.5%

最下面是每角色合表。這裡的「快取命中」按輸入 token 計算,不是按 request 次數:cache read ÷ (input + cache read + cache write)。這一欄值得多看一眼:主 agent 97.4%,每個 subagent 都在八成以上,這是這場長對話的成本沒有隨 context 等比例上升的重要原因之一:

場次報表:每角色時間×token×成本×快取命中

本日小結

接 OpenTelemetry 這件事,我學到的:

  1. 先分清楚自己要回答什麼問題。 Monitoring 觀察系統是否正常、何時偏離;這次我把一場 review 的 trace 當成流程 profiler,追的是「時間跟錢花去哪」,答案是那 803 秒的投影加驗證。
  2. 文件會過時,維度會塌。 description 看不到、自訂 agent 塌成 custom、當時的 console exporter 累積計數器不能直接加總,這些全是實測才知道的,而且行為跟著版本變,本文的數字我都標了當時的版本。
  3. 想按角色歸因,得在被量測物身上做記號。 subagent_type 是結構化歸因的主路徑,prompt 首行 tag 則保留一條人工復原線索。
  4. 對照組是量測的地基。 resource attributes 把 skill 版本跟實驗代號黏上每筆資料,沒有這層,改進做完也說不出有沒有效。

不過話說回來,這一整套數字有一個共同的前提:它們全部是 Claude Code 自己申報的,它願意記什麼,我就只看得到什麼。明天來做 Day 20 結尾預告過的那件事:把容器裡跑出去的流量,直接攤開來看。


上一篇
Day 20|容器的網路邊界:iptables 白名單,與少寫兩個引號留下的漏洞
下一篇
Day 22|一天 800 MB:用 mitmproxy 攤開 Claude Code 的流量
系列文
AI 的駕馭之道:一個 AI Code Reviewer 的養成、評測與邊界實錄30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言